서비스 계층의 트랜잭션 경계 정하기

서비스 계층의 트랜잭션 경계 정하기

한눈에 보기

트랜잭션은 Repository 메서드마다 자동으로 시작하는 기능이 아니라, 하나의 유스케이스가 함께 지켜야 하는 불변식의 경계다. 서비스가 트랜잭션의 시작과 끝을 소유하고 Repository는 전달받은 transaction context를 사용하게 하면 여러 변경을 원자적으로 묶으면서 HTTP와 DB 구현의 결합도 줄일 수 있다.

목차

트랜잭션을 어디에 둘지 어려운 이유

주문 확정 기능을 만든다고 해 보자. 필요한 작업은 다음과 같다.

한 함수에 모두 넣고 트랜잭션으로 감싸면 단순해 보이지만, DB 변경과 외부 메시지·알림은 같은 커밋에 참여하지 못한다. 반대로 Repository가 각자 커밋하면 재고만 줄고 주문은 미확정인 상태가 남을 수 있다.

경계를 정하려면 먼저 작업을 기술적인 호출 목록이 아니라 업무 상태로 바꿔 적어야 한다.

주문이 CONFIRMED라면
  - 요청한 수량이 재고에 예약되어 있어야 한다.
  - 사용하기로 한 포인트가 원장에 차감되어 있어야 한다.
  - 후속 처리를 위한 이벤트가 유실되지 않아야 한다.

앞의 두 조건은 하나의 관계형 DB 안에서 함께 커밋할 수 있다. 세 번째는 Outbox 레코드를 같은 트랜잭션에 저장하는 방식으로 “발행할 의도”까지 원자적으로 남길 수 있다. 실제 브로커 전송과 푸시 알림은 커밋 후 비동기로 수행한다.

불변식으로 경계를 찾는다

트랜잭션 경계는 “몇 개의 테이블을 수정하는가”보다 어떤 상태 조합이 중간에 관찰되면 안 되는가로 찾는다.

가상의 주문 확정 유스케이스를 상태 변화로 표시하면 다음과 같다.

flowchart TB
    A[ConfirmOrder 명령] --> B{주문이 PENDING인가}
    B -->|아니오| X[도메인 오류]
    B -->|예| C{재고 예약 가능}
    C -->|아니오| Y[품절 오류]
    C -->|예| D[재고 예약]
    D --> E[포인트 원장 기록]
    E --> F[주문 CONFIRMED]
    F --> G[Outbox 이벤트 저장]
    G --> H[COMMIT]

D부터 G까지 어느 한 단계가 실패하면 전부 되돌아가야 한다. 따라서 이 구간이 하나의 트랜잭션 후보다.

경계 판단 질문

“이 단계까지만 성공한 상태를 다른 요청이 보아도 업무적으로 유효한가?” 유효하지 않다면 같은 트랜잭션에 묶거나, 중간 상태를 명시적인 프로세스 상태로 모델링해야 한다.

모든 과정을 한 트랜잭션에 넣을 수 없는 장기 워크플로라면 PAYMENT_PENDING, PAYMENT_CONFIRMED, FULFILLMENT_PENDING처럼 중간 상태를 데이터 모델에 드러낸다. 원자성을 억지로 넓히지 않고 여러 개의 짧은 로컬 트랜잭션과 복구 가능한 상태 전이로 나눈다.

Repository마다 트랜잭션을 열면 생기는 문제

Repository가 각 메서드 내부에서 트랜잭션을 완료하는 구조를 보자.

class InventoryRepository {
  async reserve(productId: string, quantity: number): Promise<void> {
    await this.prisma.$transaction(async (tx) => {
      const changed = await tx.stock.updateMany({
        where: {
          productId,
          available: { gte: quantity },
        },
        data: {
          available: { decrement: quantity },
          reserved: { increment: quantity },
        },
      });

      if (changed.count !== 1) {
        throw new OutOfStockError();
      }
    });
  }
}

서비스가 두 Repository를 순서대로 호출한다.

async confirm(command: ConfirmOrderCommand): Promise<void> {
  await this.inventoryRepository.reserve(
    command.productId,
    command.quantity,
  );

  await this.orderRepository.confirm(command.orderId);
}

재고 예약 트랜잭션은 이미 커밋되었다. 이후 주문 업데이트가 실패해도 서비스는 첫 번째 커밋을 되돌릴 수 없다.

sequenceDiagram
    participant S as Service
    participant I as InventoryRepository
    participant O as OrderRepository
    S->>I: reserve()
    I-->>S: COMMIT
    S->>O: confirm()
    O--xS: 실패
    Note over S,O: 재고 예약만 남음

Repository가 “자기 작업은 언제나 안전하게 커밋한다”는 추상화를 제공하지만, 유스케이스 수준의 원자성을 깨뜨린다. Repository의 책임은 저장과 조회 방법을 감추는 것이지, 상위 유스케이스의 커밋 시점을 독자적으로 정하는 것이 아니다.

단일 메서드의 원자성은 다른 문제다

UPDATE stock SET available = available - ? WHERE available >= ?처럼 한 SQL 자체가 원자적이어야 하는 것은 맞다. 이것은 Repository 메서드 내부의 구현 원칙이다. 그러나 해당 SQL을 즉시 커밋할지 다른 주문 변경과 함께 커밋할지는 서비스 유스케이스가 결정한다.

Controller에서 트랜잭션을 열면 생기는 문제

반대편 극단은 HTTP controller가 ORM client를 직접 다루는 구조다.

@Post(":orderId/confirm")
async confirm(@Param("orderId") orderId: string): Promise<void> {
  await this.prisma.$transaction(async (tx) => {
    await this.orderService.confirm(tx, { orderId });
  });
}

동작은 할 수 있지만 다음 문제가 생긴다.

Controller는 DTO를 명령으로 변환하고 결과를 HTTP 응답으로 바꾸는 역할에 집중하는 편이 좋다.

@Post(":orderId/confirm")
async confirm(
  @Param("orderId") orderId: string,
  @Body() body: ConfirmOrderDto,
): Promise<ConfirmOrderResponse> {
  const result = await this.confirmOrder.execute({
    orderId,
    requestId: body.requestId,
  });

  return { orderId: result.orderId, status: result.status };
}

Application Service가 경계를 소유하게 만들기

Application Service는 하나의 유스케이스 흐름을 조정하므로 트랜잭션 경계를 소유하기 좋은 위치다.

class ConfirmOrderService {
  constructor(
    private readonly unitOfWork: UnitOfWork,
    private readonly clock: Clock,
  ) {}

  async execute(command: ConfirmOrderCommand): Promise<ConfirmOrderResult> {
    return this.unitOfWork.run(async (context) => {
      const duplicated = await context.orders.findByRequestId(
        command.requestId,
      );

      if (duplicated) {
        return {
          orderId: duplicated.id,
          status: duplicated.status,
        };
      }

      const order = await context.orders.findById(command.orderId);

      if (!order) {
        throw new OrderNotFoundError(command.orderId);
      }

      order.confirm(this.clock.now());

      await context.inventory.reserve(
        order.productId,
        order.quantity,
      );
      await context.points.debit(order.userId, order.pointAmount);
      await context.orders.save(order, command.requestId);
      await context.outbox.append(
        OrderConfirmedEvent.from(order),
      );

      return {
        orderId: order.id,
        status: order.status,
      };
    });
  }
}

서비스는 BEGIN, COMMIT 같은 DB 세부 문법을 모르지만 callback 반환과 예외를 기준으로 원자적 범위를 명확히 보여 준다. context 안의 모든 Repository는 같은 transaction client를 사용한다.

이 구조에서 중요한 점은 도메인 모델이 트랜잭션을 시작하지 않는다는 것이다. order.confirm()은 상태 전이 규칙만 검사하고 객체 상태를 바꾼다. 커밋 단위는 여러 aggregate를 조정하는 Application Service가 결정한다.

Unit of Work 인터페이스 설계

도메인 코드가 Prisma 타입에 의존하지 않게 단순한 인터페이스를 정의할 수 있다.

type TransactionContext = Readonly<{
  orders: OrderRepository;
  inventory: InventoryRepository;
  points: PointRepository;
  outbox: OutboxRepository;
}>;

interface UnitOfWork {
  run<T>(
    work: (context: TransactionContext) => Promise<T>,
  ): Promise<T>;
}

Prisma 어댑터는 callback마다 같은 tx로 Repository들을 만든다.

class PrismaUnitOfWork implements UnitOfWork {
  constructor(private readonly prisma: PrismaClient) {}

  run<T>(
    work: (context: TransactionContext) => Promise<T>,
  ): Promise<T> {
    return this.prisma.$transaction(async (tx) => {
      const context: TransactionContext = {
        orders: new PrismaOrderRepository(tx),
        inventory: new PrismaInventoryRepository(tx),
        points: new PrismaPointRepository(tx),
        outbox: new PrismaOutboxRepository(tx),
      };

      return work(context);
    });
  }
}

Repository 생성 비용이 걱정되면 transaction client를 명시적으로 인자로 전달하는 방식도 가능하다.

interface OrderRepository {
  findById(
    id: string,
    context: TransactionHandle,
  ): Promise<Order | null>;
}

하지만 모든 호출에 context 인자가 퍼지고 빠뜨릴 가능성이 있다. 어느 방식이든 다음 조건을 만족하는지가 중요하다.

  1. 트랜잭션 안의 모든 DB 작업이 같은 연결과 context를 사용한다.
  2. 서비스 코드에서 경계의 시작과 끝이 보인다.
  3. 테스트에서 커밋과 롤백 동작을 확인할 수 있다.
  4. 전역 client를 실수로 사용하는 경로를 줄인다.
추상화의 함정

범용 Repository<T>와 범용 Unit of Work를 만들려고 지나치게 추상화하면 ORM의 조건부 UPDATE, bulk operation, locking 기능을 쓰기 어려워진다. 유스케이스에 필요한 동작을 도메인 언어로 노출하는 편이 낫다.

중첩 유스케이스와 트랜잭션 전파

서비스 A가 서비스 B를 호출하고 둘 다 unitOfWork.run()을 시작하면 중첩 트랜잭션 문제가 생긴다.

class ConfirmOrderService {
  async execute(command: ConfirmOrderCommand): Promise<void> {
    await this.unitOfWork.run(async () => {
      await this.reserveInventoryService.execute(command);
    });
  }
}

내부 서비스가 새 트랜잭션을 열면 바깥 롤백과 별개로 먼저 커밋될 수 있다. 사용하는 DB와 ORM이 savepoint나 중첩 transaction을 어떻게 처리하는지도 다르다.

가장 단순한 해법은 트랜잭션을 여는 유스케이스같은 context에서 실행되는 내부 작업을 구분하는 것이다.

class InventoryReservation {
  async reserve(
    context: TransactionContext,
    productId: string,
    quantity: number,
  ): Promise<void> {
    await context.inventory.reserve(productId, quantity);
  }
}

최상위 Application Service만 경계를 열고 내부 협력 객체에는 context를 전달한다.

또 다른 방식은 이미 열린 context가 있으면 참여하고 없으면 새로 여는 전파 정책이다.

interface TransactionManager {
  required<T>(work: () => Promise<T>): Promise<T>;
  requiresNew<T>(work: () => Promise<T>): Promise<T>;
}

이 방식은 편리하지만 async context 전달과 requiresNew 의미가 복잡해진다. 대부분의 서비스에서는 명시적인 최상위 경계부터 시작하는 편이 디버깅하기 쉽다.

조회 서비스까지 무조건 참여시키지 않는다

트랜잭션 안에서 필요한 조회와 화면 표시용 조회를 구분한다. 주문을 확정하기 위해 현재 상태를 읽는 것은 같은 경계에 있어야 하지만, 응답에 추천 상품을 붙이는 조회는 커밋 후 실행해도 된다.

const result = await this.unitOfWork.run((context) =>
  this.confirmInsideTransaction(context, command),
);

const recommendations = await this.recommendationService.findFor(
  result.userId,
);

return { ...result, recommendations };

외부 API와 메시지는 같은 트랜잭션에 들어가지 않는다

DB 트랜잭션 안에서 메시지 브로커에 이벤트를 발행하는 코드를 생각해 보자.

await prisma.$transaction(async (tx) => {
  await tx.order.update({
    where: { id: orderId },
    data: { status: "CONFIRMED" },
  });

  await broker.publish("order.confirmed", { orderId });
});

두 가지 실패 순서가 있다.

순서 결과
이벤트 발행 성공 → DB 커밋 실패 존재하지 않는 확정 주문 이벤트가 전달됨
DB 커밋 성공 → 이벤트 발행 실패 확정 주문의 후속 처리가 영원히 누락될 수 있음

COMMIT 다음 줄에서 발행해도 두 번째 문제는 남는다.

Outbox Pattern은 도메인 변경과 발행할 이벤트를 같은 DB 트랜잭션에 저장한다.

await this.unitOfWork.run(async (context) => {
  const order = await context.orders.findById(orderId);
  order.confirm(now);

  await context.orders.save(order);
  await context.outbox.append({
    id: crypto.randomUUID(),
    topic: "order.confirmed",
    aggregateId: order.id,
    payload: {
      orderId: order.id,
      userId: order.userId,
    },
    occurredAt: now,
  });
});

별도 worker가 미발행 outbox를 읽어 브로커로 전송하고 성공 상태를 기록한다. worker가 발행 후 상태 기록 전에 죽으면 이벤트가 중복될 수 있으므로 소비자 멱등성도 필요하다.

sequenceDiagram
    participant API
    participant DB
    participant W as Outbox Worker
    participant B as Broker
    API->>DB: 주문 변경 + Outbox INSERT
    DB-->>API: COMMIT
    W->>DB: 미발행 Outbox 조회
    W->>B: 이벤트 발행
    B-->>W: ACK
    W->>DB: published_at 기록

푸시 알림, 이메일, 검색 인덱싱도 같은 원리로 커밋 후 후속 작업으로 분리한다.

긴 트랜잭션을 짧게 만드는 방법

트랜잭션이 길어지면 연결 점유, 잠금 대기, deadlock 가능성이 증가한다. 경계를 줄이는 실용적인 방법은 다음과 같다.

트랜잭션 밖에서 할 수 있는 검증은 먼저 한다

DTO 형식 검증, 인증 토큰 파싱, 정적 설정 조회는 시작 전에 끝낸다. 다만 DB 상태에 의존하고 커밋 시점까지 유지되어야 하는 조건은 트랜잭션 안에서 다시 보장한다.

const normalized = normalizeCommand(command);
const policy = await this.policyCache.get(command.policyId);

return this.unitOfWork.run((context) =>
  this.executeWithPolicy(context, normalized, policy),
);

외부 호출은 상태 머신으로 분리한다

결제 승인이나 배송사 접수는 DB 트랜잭션 밖에서 멱등성 키로 호출한다. 결과를 별도 짧은 트랜잭션에 반영한다.

필요한 행만 읽고 쓴다

await tx.order.findUnique({
  where: { id: orderId },
  select: {
    id: true,
    status: true,
    userId: true,
    totalAmount: true,
  },
});

큰 JSON, 관계 전체를 불필요하게 가져오지 않는다.

잠금 순서를 일관되게 한다

여러 지갑이나 재고 행을 수정한다면 ID를 정렬해 항상 같은 순서로 접근하면 deadlock 가능성을 줄일 수 있다.

const walletIds = [sourceWalletId, targetWalletId].sort();

for (const walletId of walletIds) {
  await lockWallet(tx, walletId);
}

실패 시나리오와 테스트

정상 경로 테스트만으로 트랜잭션 경계를 검증할 수 없다. 각 쓰기 사이에 실패를 주입해 이전 변경이 남지 않는지 확인한다.

describe("ConfirmOrderService", () => {
  it("포인트 차감 실패 시 재고 예약도 롤백한다", async () => {
    await seedPendingOrder({
      orderId: "order_100",
      stock: 5,
      pointBalance: 0,
    });

    await expect(
      service.execute({
        orderId: "order_100",
        requestId: "request_100",
      }),
    ).rejects.toBeInstanceOf(InsufficientPointError);

    expect(await readAvailableStock()).toBe(5);
    expect(await readOrderStatus()).toBe("PENDING");
    expect(await countOutboxEvents()).toBe(0);
  });
});

Outbox까지 같은 경계인지 별도 테스트한다.

it("주문 확정과 outbox 레코드가 함께 커밋된다", async () => {
  await service.execute(validCommand());

  const [order, events] = await Promise.all([
    readOrder(),
    readOutboxEvents("order.confirmed"),
  ]);

  expect(order.status).toBe("CONFIRMED");
  expect(events).toHaveLength(1);
  expect(events[0].aggregateId).toBe(order.id);
});

동일 requestId가 동시에 들어오는 멱등성 테스트도 필요하다.

const results = await Promise.allSettled([
  service.execute(validCommand()),
  service.execute(validCommand()),
]);

expect(await countPointLedgerEntries()).toBe(1);
expect(await countOutboxEvents()).toBe(1);
expect(results.some((result) => result.status === "fulfilled")).toBe(true);

메모리 fake Unit of Work는 도메인 분기를 빠르게 테스트하는 데 유용하지만, 실제 롤백과 UNIQUE 제약, 격리 수준은 검증하지 못한다. 핵심 실패 경로는 실제 DB 통합 테스트로 남긴다.

운영에서 경계를 검증하는 지표

설계한 경계가 운영에서 너무 크거나 잘못 전파되는지 다음 지표로 확인할 수 있다.

트랜잭션 시작·종료 로그에는 유스케이스 이름과 request ID를 넣는다.

logger.info({
  event: "transaction.completed",
  usecase: "confirm_order",
  requestId,
  durationMs,
  queryCount,
  outcome: "committed",
});

DB 트랜잭션이 끝난 후 controller의 응답 직렬화가 실패할 수도 있다. 클라이언트는 실패 응답을 받았지만 주문은 이미 확정된 경우다. 따라서 쓰기 API는 요청 멱등성 키와 결과 재조회 방법을 제공해야 한다.

HTTP 성공과 커밋 성공은 같은 사건이 아니다

커밋 후 연결이 끊기면 클라이언트는 결과를 모른다. “응답을 못 받았으니 다시 실행”해도 중복 변경이 없도록 유스케이스 자체를 멱등하게 만든다.

정리

서비스 계층의 트랜잭션 경계는 ORM 편의 기능으로 정하는 것이 아니다. 먼저 하나의 유스케이스가 유지해야 하는 불변식을 문장으로 쓰고, 부분 성공 상태가 유효한지 판단한다.

트랜잭션의 크기는 호출 스택이 아니라 불변식의 크기여야 한다. 하나의 업무 명령이 보장해야 할 최소 범위를 원자적으로 묶고, 그 밖의 작업은 복구 가능한 후속 단계로 분리한다.

관련 노트